iT邦幫忙

2021 iThome 鐵人賽

DAY 24
0

前言

我們在併發HTTP Server的時候,經常有對接口內容做緩存的需求。例如:某些熱點內容,我們希望在1分鐘內做緩存,避免短時間內不會對相同的內容進行重複性讀取與運算,同時也降低系統的整體負載。

有時我們需要把緩存邏輯放在Server內部,而非網觀測如Nginx等。是因為這樣我們可以根據需求便捷地清除緩存,或者利用Redis等其他儲存空間做為緩存後端。

也因此需要快取的資料有兩種特徵:

  • 經常被調用
  • 資料不常變動

那快取的種類也可依不同類型分為三種:

  • Client Cache
  • Server Cache
  • Network Cache

Client Cache

Client Cache指的是伺服器與瀏覽器之間的快取機制,Server Side透過伺服器的快取將不常變動的資訊儲存在瀏覽器快取之中,避免重複下載浪費效能。

Client Cache的設定方式是透過HTTP Request的Header去帶params,當瀏覽器接收到特定params就會採取相對應的快取處理。

如果對Client Cache有興趣的人可以參考下方連結,胡立大寫的很好

https://blog.techbridge.cc/2017/06/17/cache-introduction/

Server Cache

Server Cache主要指的是在Backend 與 Database間資料的Cache。當大量的Query Request進來時,會導致Database的I/O操作過多,因而造成Session堵塞、效能低落等問題,即使進行讀寫分離在不同的叢集當中,也只能解決部份問題,因此我們會將經常被查詢的資料儲存在像是Redis之類的key-value資料庫,並以LRU等Strategy來進行快取資料的變更,以分擔資料庫I/O壓力。而我們這章節主要也是在講Gin在Server Cache的實作!

有興趣者也可以參考Cloudflare的官方資訊,他們解說的挺詳細的

https://www.cloudflare.com/zh-tw/cdn/

Network Cache

最後則是Network Cache,他的理念即是User會從離他最近的Server去取資料,用以節省Response Time,也就是CDN(Content Delivery Network)的概念!

這樣的緩存場景無非是有緩存時從緩存取,無緩存時從下游服務取,並將數據放入緩存中。這其實是非常通用的邏輯,應該可以將其抽象出來。從而緩存邏輯無需侵入進業務邏輯

Gin with Cache

這邊我們選用的是yahui大神所重新封裝的gin-cache package,因其可以依據自身要求定義cache key, 且在性能方面也較官方的gin-contrib/cache優秀,因此選用它。

Installation

go get -u [github.com/chenyahui/gin-cache](http://github.com/chenyahui/gin-cache%E3%80%82)

Router with Cache

app/config/router.go

package config

import (
	cache "github.com/chenyahui/gin-cache"
	"github.com/chenyahui/gin-cache/persist"
	"github.com/gin-gonic/gin"
	"ironman-2021/app/controller"
	"ironman-2021/app/middleware"
	_ "ironman-2021/docs"
	"time"
)

func RouteUsers(r *gin.Engine, m *persist.RedisStore) {
	posts := r.Group("/v1/users")
	{
		posts.POST("/", controller.NewUsersController().CreateUser)
		posts.GET("/:id", middleware.JWTAuthMiddleware(), cache.CacheByRequestURI(m, 2*time.Hour),
			controller.QueryUsersController().GetUser)
		posts.POST("/login", controller.LoginUserController().AuthHandler)
	}
}
  • 我們讓GET /v1/users/:id 這個endpoint吃得到快取,然後快取時間設定為2小時

main.go

package main

import (
	"github.com/chenyahui/gin-cache/persist"
	"github.com/gin-gonic/gin"
	"github.com/go-redis/redis/v8"
	"github.com/joho/godotenv"
	ginSwagger "github.com/swaggo/gin-swagger"
	"github.com/swaggo/gin-swagger/swaggerFiles"
	"ironman-2021/app/config"
	"ironman-2021/app/dao"
	"ironman-2021/app/middleware"
	"ironman-2021/app/model"
	"os"
)

// @title Gin swagger
// @version 1.0
// @description Gin swagger

// @contact.name Flynn Sun

// @license.name Apache 2.0
// @license.url http://www.apache.org/licenses/LICENSE-2.0.html

// @host localhost:8080
// schemes http
// @securityDefinitions.apikey BearerAuth
// @in header
// @name Authorization
func main() {
	envErr := godotenv.Load()
	if envErr != nil {
		panic(envErr)
	}

	port := os.Getenv("PORT")
	dbConfig := os.Getenv("DB_CONFIG")
	db, ormErr := dao.Initialize(dbConfig)
	if ormErr != nil {
		panic(ormErr)
	}
	migrateErr := db.AutoMigrate(&model.User{})
	if migrateErr != nil {
		return
	}

	server := gin.Default()
	server.Use(middleware.CORSMiddleware())
	server.GET("/hc", func(c *gin.Context) {
		c.JSON(200, gin.H{
			"message": "health check",
		})
	})
	redisStore := persist.NewRedisStore(redis.NewClient(&redis.Options{
		Network: "tcp",
		Addr:    "redis:6379",
		DB: 0,
	}))
	config.RouteUsers(server, redisStore)
	url := ginSwagger.URL("http://localhost:8080/swagger/doc.json")
	server.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler, url))
	err := server.Run(":" + port)
	if err != nil {
		panic(err)
	}
}

Performance

我們可以看到下圖在第一次GET /v1/users/:id與之後幾次的Response Time都差距相當的多,因為第二次之後都是從Cache拿取資料

https://ithelp.ithome.com.tw/upload/images/20211009/20129737Y50NIzIM2C.png

此外我們也可以進去redis的container,並觀察到當第一次GET /v1/users/:id

後,會多一個/v1/users/1的Key,我們就以Key-Value的資料去讀取快取

docker exec -it redis bash

root@e2c9049d9ac3:/data# redis-cli -n 0
127.0.0.1:6379> KEYS *
(empty array)
127.0.0.1:6379> KEYS *
1) "/v1/users/1"
127.0.0.1:6379>

題外話,能夠如此容易的使用各項服務與監測效能也是我們用dockerdocker-compose的好處之一。

gin-contrib/cache的問題解析

接口設計

  1. gin-contrib/cache 對外提供的方式是wrap handler的方式,而非更優雅的middleware。
cache.CachePage(store, time.Minute, func(c *gin.Context) {
		c.String(200, "pong"+fmt.Sprint(time.Now().Unix()))
})
  1. 用戶無法根據自身要求自定義生成cache key。gin-contrib/cache只提供CachePage、CachePageWithoutQuery等函數,用戶可以根據url作為緩存的key。但該組件並不支持自定義cache key。

性能方面

  1. 該組件寫入緩存的方式是,重載了ResponseWriter的寫入函數。每次在gin中調用寫入函數時,觸發一次性能的獲取和追加操作。比較差的。
  2. 最糟糕的是關於並發安全的實現。因為該組件寫緩存之前需要先得到原始內容進行生成,這個過程不是原子的。加了一個互斥鎖來保證不會寫衝突,接口代碼如下。
func CachePageAtomic(store persistence.CacheStore, expire time.Duration, handle gin.HandlerFunc) gin.HandlerFunc {
	var m sync.Mutex
	p := CachePage(store, expire, handle)
	return func(c *gin.Context) {
		m.Lock()
		defer m.Unlock()
		p(c)
	}
}

緩存擊穿問題

在緩存設計中,會遇到一個常見的問題:緩存擊穿。緩存擊穿指的是:當某個熱點Key在其緩存過期的一瞬間,大量的請求將訪問不到這個Key對應的緩存。這時請求將直接打到下游的儲存或服務當中。一瞬間大量的請求,可能會對下游服務造成極大的壓力。

關於這個問題,golang 官方有一個Single Flight:golang.org/x/sync/singleflight,可以有效的解決緩存擊穿問題。其原理非常簡單,有興趣的可以直接在 Github 搜源碼看就可以了,到此不再展開討論。

benchmark

使用Linux CPU 8核,16G內存系統配置下,Cyhone大使用wrk對gin-contrib/cachegin-cache做benchmark壓力測試,這邊我則擷取他的實驗結果來解說,有興趣者可以去Reference的連結轉連。

wrk -c 500 -d 1m -t 5 http://127.0.0.1:8080/hello

https://ithelp.ithome.com.tw/upload/images/20211009/201297374A8KSZV4bJ.png
https://ithelp.ithome.com.tw/upload/images/20211009/201297372ZL1YLaDLU.png

  • 對於MemoryCache 進程內緩存這個場景,gin-cache 提升了23%。
  • 對於Redis做緩存這個場景,gin-cache相比QPS更提升了30倍左右。當然也使用gin-cache使用的redis客戶端庫的性能更好。
  • 從口的設計對比來看,在當處理者請求接手,gin-cache 的優勢將更加明顯。

Reference

https://blog.techbridge.cc/2017/06/17/cache-introduction/

https://www.cloudflare.com/zh-tw/cdn/

https://www.cyhone.com/articles/gin-cache/


上一篇
Day23 Gin with i18n
下一篇
Day25 Gin with API Test
系列文
fmt.Println("從零開始的Golang生活")30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言